<?php

namespace App\Command\ResumeParsing\Service;

//Multipart文件统一处理类
class MultipartReader implements \Iterator {
    private $start = 0;
    private $pos = 0;
    private $content = '';
    private $boundary = '';
    private $currConfig = '';
    private $currContent = '';
    private $index = 0;
    private $current = false;

    /**
     * @var int 部分载入文件的字节数
     */
    public $peekLength = 1000;

    /**
     * @var bool|string|array 自动处理正文，转换编码 false-不处理；true-处理全部；字符串（支持逗号分隔的多项内容）或数组-只处理指定类型
     */
    public $autoProcess = 'html';

    /**
     * @var bool 文件是合法multipart格式则为true，否则为false
     */
    public $isValid = false;

    /**
     * @var array 文件头信息
     */
    public $info = [];

    /**
     * MultipartReader 构造函数
     * @param string $file 文件路径，或文件内容（可选）。若指定此参数，则实例化后立刻载入文件
     * @param bool $fromString 为true时，$file参数为文件内容；否则为文件路径
     */
    function __construct($file = '', $fromString = false) {
        if (!empty($file)) $this->read($file, $fromString);
    }

    /**
     * 载入multipart文件
     * @param string $file 文件路径，或文件内容
     * @param bool $fromString 为true时，$file参数为文件内容；否则为文件路径
     * @param bool $test 为true时仅用于判断文件是否为合法multipart，不会载入完整文件
     * @return bool 文件是合法multipart格式则为true，否则为false。和isValid属性值相同
     */
    public function read(string $file, $fromString = false, $test = false) {
        $this->isValid = false;
        $this->pos = 0;
        $this->boundary = '';
        $this->info = [];
        if ($fromString) {
            $this->content =& $file;
        } else {
            if (!file_exists($file)) {
                return false;
            }
            $this->content = file_get_contents($file, false, null, 0, $this->peekLength);
        }
        if (!$this->sliceNext()) {
            return false;
        }
        $this->info = $this->getConfigBlock();
        if (!empty($this->info['content-type']['value']) && strpos($this->info['content-type']['value'], 'multipart') === 0
            && !empty($this->info['content-type']['attributes']['boundary'])) {
            $this->boundary = preg_quote($this->info['content-type']['attributes']['boundary'], '/');
            if (!$fromString && !$test) $this->content = file_get_contents($file);  //判断文件合法后再整体载入，减少不必要的开销
            $this->isValid = true;
        }
        return $this->isValid;
    }

    /**
     * 测试multipart文件合法性，为read(..., $test=true)的别名
     * @param string $file 文件路径，或文件内容
     * @param bool $fromString 为true时，$file参数为文件内容；否则为文件路径
     * @return bool 文件是合法multipart则为true，否则为false。和isValid属性值相同
     */
    public function test(string $file, $fromString = false) {
        return $this->read($file, $fromString, true);
    }

    /**
     * 根据MIME信息匹配类型（MIME类型包含指定关键字即可，无需完全相等）
     * @param array $part 区块数据结构
     * @param string|array|bool $types MIME类型关键字。支持数组和逗号分隔的字符串
     * @return bool 若$types中任一关键词匹配或$types为true，则返回true，否则返回false
     */
    public function contentIs(array $part, $types) {
        if (is_bool($types)) return $types;
        if (!is_array($part) || !isset($part['content-type']) || !is_array($part['content-type']) || !is_string($part['content-type']['value'])) return false;
        if (is_string($types)) $types = explode(',', $types);
        elseif (!is_array($types)) return false;
        $contentType = strtolower($part['content-type']['value']);
        foreach ($types as $type) {
            if (strpos($contentType, strtolower(trim($type))) !== false) return true;
        }
        return false;
    }

    /**
     * 获取下一个区块
     * @return array|false 包含区块配置和内容的数据结构。若无法获取到下一个区块（已到达文件末尾），则返回false
     */
    public function getPart() {
        if (!$this->isValid) {
            trigger_error('input is not a valid multipart file!', E_USER_WARNING);
            return false;
        }
        if (!$this->sliceNext()) return false;
        if (empty($part = $this->getConfigBlock()) && empty($this->currContent)) return false;
        $part['Content'] = $this->currContent;
        if ($this->contentIs($part, $this->autoProcess)) {
            //当content-transfer-encoding字段未被匹配读取到时，会导致无法识别
            //采用正则匹配的方式进行补救
            if (!isset($part['content-transfer-encoding']['value'])) {
                $result = preg_match('/content-transfer-encoding:.*\s/',strtolower($this->currConfig),$match);
                if ($result) {
                    $part['content-transfer-encoding']['value'] = trim(str_replace('content-transfer-encoding:','',$match[0]));
                } else {
                    $part['content-transfer-encoding']['value'] = '';
                }
            }
            switch (strtolower($part['content-transfer-encoding']['value'])) {
                case 'quoted-printable':
                    $part['Content'] = @quoted_printable_decode($part['Content']);
                    $part['content-transfer-encoding']['value'] = 'binary';
                    break;
                case 'base64':
                    $part['Content'] = @base64_decode($part['Content']);
                    $part['content-transfer-encoding']['value'] = 'binary';
                    break;
                default:
            }
            if ($this->contentIs($part, 'html,xml')) {
                $part['Content'] = preg_replace('/(<meta.*content\s*=\s*"\s*text\/html\s*;\s*charset\s*=\s*)(?:gb\d+)(">)/i', '\1gbk\2', $part['Content'], 1);    //GBK内容声明为GB2312时，把编码声明篡改回GBK
            }
            /*			if(isset($part['content-type']['attributes']['charset'])){
                            $part['Content'] = @iconv($part['content-type']['attributes']['charset'], "utf-8//IGNORE", $part['Content']);	//自行转换编码后会导致和html标签中的charset属性不一致
                            if(in_array(strtolower($part['content-type']['attributes']['charset']), array('utf8', 'utf-8'))){	//此段对DOM类加载HTML无必要，并且有破坏非UTF-8多字节文本的风险
                                mb_internal_encoding("UTF-8");
                                mb_regex_encoding('utf-8');
                                $part['Content'] = mb_ereg_replace("[\r\n\t　]", " ",$part['Content']);
                                $part['Content'] = mb_ereg_replace("\=\r\n","\r\n",$part['Content']);
                                $part['Content'] = mb_ereg_replace("<!--.*-->"," ",$part['Content']);
                            } else {
                                $part['Content'] = preg_replace("/[\r\n\t　]/", " ",$part['Content']);
                                $part['Content'] = preg_replace("/\=\r\n/","\r\n",$part['Content']);
                                $part['Content'] = preg_replace("/<!--.*-->/"," ",$part['Content']);
                            }
                        }
            */
        }
        return $part;
    }

    /**
     * 切割出下一区块的文本段，存放于currConfig和currContent变量中，并更新pos指针
     * @return bool 成功返回true，否则返回false
     */
    private function sliceNext() {
        if (empty($this->boundary)) {
            if (!preg_match('/^(?<config>.+?)(?:\n\s*\n(?<content>.+?))??(?=\s*\n\s*\n\s*--|$)(?<next>)/s', $this->content, $matches, PREG_OFFSET_CAPTURE)) return false;
            $this->pos = $matches['next'][1];
            $this->currConfig = $matches['config'][0];
            $this->currContent = $matches['content'][0] ?? '';
            if (empty($this->boundary) && preg_match('/--' . $this->boundary . '(?:[\r\n]|$|--)\s*(?<next>)/',
                    $this->content, $matches, PREG_OFFSET_CAPTURE, $this->pos)) {
                $this->start = $this->pos = $matches['next'][1];
            }
            return true;
        } else {
            if (preg_match('/\n\s*--' . $this->boundary . '(?:--)?\s*(?<next>)/', $this->content, $matches, PREG_OFFSET_CAPTURE, $this->pos)) {
                $contentEnd = $matches[0][1];
                $nextBegin = $matches['next'][1];
            } else {
                $contentEnd = $nextBegin = strlen($this->content);
            }
            if (preg_match('/\n\s*\n(?<next>)/', $this->content, $matches, PREG_OFFSET_CAPTURE, $this->pos)) {
                $configEnd = $matches[0][1];
                $contentBegin = $matches['next'][1];
            } else {
                $configEnd = $contentEnd;
            }
            $this->currConfig = substr($this->content, $this->pos, $configEnd - $this->pos);
            $this->currContent = !empty($contentBegin) && $contentBegin < $contentEnd ? substr($this->content, $contentBegin, $contentEnd - $contentBegin) : '';
            if ($nextBegin <= $this->pos) return false;
            $this->pos = $nextBegin;
            return true;
        }
    }

    /**
     * 读取当前区块的配置信息
     * @return array 包含区块内所有配置信息的数据结构
     */
    private function getConfigBlock() {
        preg_match_all('/[^\S\r\n]*(?<name>[\w-]+?)[^\S\r\n]*:[^\S\r\n]*(?<value>[^;]+?)[^\S\r\n]*(?:;\s*(?<attributes>\S.*(?:(?:\r\n|[\r\n])[^\S\r\n]*\S.*)*?))?(?:\r\n|[\r\n]|$)(?!\s*[\w-]{1,50}=[^=\r\n])/',
            $this->currConfig, $matches, PREG_SET_ORDER);
        $result = [];
        foreach ($matches as $lineData) {
            $line = $this->getConfigLine($lineData);
            $result[strtolower($line['name'])] = $line;
        }
        return $result;
    }

    /**
     * 读取一行配置信息
     * @param array $lineData 已通过正则匹配到各个字段的数据
     * @return array 单项配置信息数据结构
     */
    private function getConfigLine(array $lineData) {
        $result = ['name' => $lineData['name'], 'value' => $lineData['value']];
        if (!empty($lineData['attributes']) && !empty($attr = $this->getAttr($lineData['attributes']))) {
            $result['attributes'] = $attr;
        }
        //部分multipart类型文件没读取attributes的信息导致无法解析，这里进行补充
        if (empty($result['attributes'])) {
            foreach ($lineData as $datum) {
                if (preg_match('/boundary=".*?"/',$datum,$match)){
                    $result['attributes']['boundary'] = substr($match[0],10,-1);
                }

            }
        }
        return $result;
    }

    /**
     * 解析一行配置信息中的属性键值对
     * @param string $text 描述属性内容的文本区域
     * @return array 属性字典
     */
    private function getAttr(string $text) {
        preg_match_all('/(?<=^|;)[^\S\r\n]*(?<name>[\w-]+)[^\S\r\n]*=[^\S\r\n]*(?<quote>")?(?<value>(?<=")(?:[^"\r\n]+)(?=")|[^;\r\n]+)(?(quote)")[^\S\r\n]*(?=$|;)/m',
            $text, $matches, PREG_SET_ORDER);
        $result = [];
        foreach ($matches as $attr) {
            $result[strtolower($attr['name'])] = $attr['value'];
        }
        return $result;
    }

    //以下方法实现Iterator接口
    function rewind() {
        $this->pos = $this->start;
        $this->index = -1;
        $this->next();
    }

    function current() {
        return $this->current;
    }

    function key() {
        return $this->index;
    }

    function next() {
        $this->current = $this->getPart();
        if ($this->valid()) $this->index++;
    }

    function valid() {
        return $this->current !== false;
    }
}
